Skip to content

feat: TextField component#4909

Open
wonderlul wants to merge 1 commit into
callstack:v6from
wonderlul:feat/TextField-v6
Open

feat: TextField component#4909
wonderlul wants to merge 1 commit into
callstack:v6from
wonderlul:feat/TextField-v6

Conversation

@wonderlul
Copy link
Copy Markdown
Collaborator

@wonderlul wonderlul commented Apr 30, 2026

Motivation

File structure
The component is split by variant (filled/ and outlined/) and a root that wires shared behavior. Each area keeps logic, styles, utils, and constants in separate files. That follows patterns already used elsewhere in the library, but goes one step further so responsibilities stay obvious: variant-specific layout and theming do not drown in shared code, and the public API file stays focused on behavior and types.

LeftAccessory / RightAccessory vs TextInput adornments
TextInput composes leading and trailing content through left and right, which are built around icons and affixes (TextInput.Icon, TextInput.Affix) and internal adornment types. TextField instead exposes LeftAccessory and RightAccessory as render props (component types). The field passes curated layout and state—notably the merged style for alignment with the field, plus status, multiline, and editable—so accessories stay aligned with the input without re-implementing field internals. That supports arbitrary leading/trailing UI (clear actions, custom buttons, non-icon content) while still inheriting the important structural styles from the component.

filled / outlined instead of flat / outlined
Material Design 3 describes text fields in terms of filled and outlined styles. The existing TextInput API uses mode: 'flat' | 'outlined', where “flat” corresponds to the filled look. The new component names variants filled and outlined so the public API matches MD3 language and is easier to map from the spec and design tools.

Style overrides
TextField is built as a small stack of clear layers, and each layer can be adjusted without fighting the rest. The outer pressable wrapper, the field shell (border, background, row that includes accessories), and the inner content wrapper (label + TextInput) each accept dedicated style props (pressableStyle, fieldStyle, containerStyle). The underlying TextInput still uses the normal style prop (and the rest of TextInputProps) for typography, padding, and input-specific layout. Label and helper text can be customized through labelProps and helperProps (including style). Leading/trailing UI uses LeftAccessory / RightAccessory, which receive a prepared style from the field so custom content stays aligned while remaining fully under your control. Together, this gives predictable “override the part you mean” behavior instead of a single opaque style that’s hard to reason about.

TextField instead of TextInput
Material Design 3 uses the term text field for this control. Exporting a second “Paper” input named TextInput would also blur the built-in React Native TextInput in imports and documentation. A dedicated TextField name keeps the design-system component clearly namespaced and aligned with MD3, while the underlying control remains React Native’s TextInput where appropriate.

Positioning relative to TextInput
TextField is intended as the modern replacement path for form text entry in react-native-paper: implementation is structured for clarity and maintainability, and it adopts MD3-oriented theming (including use of the PlatformColor API where it fits platform tokens). Compared to the legacy TextInput stack, this design aims to be easier to follow, less ad hoc in how variants and layout are split, and more efficient in how styles and state are applied—giving teams a refreshed, spec-aligned building block for new work without forcing an immediate break for existing TextInput users.

Order of merging

#4912

Related issue

#4878
#4329
#4235

Test plan

Run the Example app.

filled
text-field-filled

outlined
text-field-outlined

@callstack-bot
Copy link
Copy Markdown

callstack-bot commented Apr 30, 2026

Hey @wonderlul, thank you for your pull request 🤗. The documentation from this branch can be viewed here.

@wonderlul wonderlul force-pushed the feat/TextField-v6 branch from 146de2a to a1a20fd Compare May 4, 2026 10:27
Comment thread example/src/Examples/TextFieldExample.tsx Outdated
Comment thread src/components/TextField/TextField.tsx
Comment thread src/components/TextField/TextFieldIcon.tsx Outdated
Comment thread src/components/TextField/filled/logic.ts Outdated
Comment thread src/components/TextField/utils.ts Outdated
Comment thread example/src/Examples/TextFieldExample.tsx
Comment thread example/src/Examples/TextFieldExample.tsx Outdated
Comment thread src/components/TextField/filled/logic.ts Outdated
Comment thread src/components/TextField/filled/logic.ts Outdated
Comment thread src/components/TextField/outlined/logic.ts Outdated
@adrcotfas
Copy link
Copy Markdown
Collaborator

Here's a comment from my friend Claude about duplicate code:

logic.ts -- highest duplication (~28%)

The most significant overlap. Both files share these sections verbatim:

Block Lines each
Props destructuring ~13
isRTL extraction 1
getLabelColor(...) call ~5
getSupportingTextColor(...) call ~5
$animatedLabelTextStyles array ~9
$containerStyles array ~4
$supportingTextStyles array ~8
$counterStyles array ~8
$prefixStyles array ~6
$suffixStyles array ~6
$leadingAccessoryStyles ~3
$trailingAccessoryStyles ~3
Total ~71 identical lines

Outlined has 233 lines, filled has 280 -- so ~71/233 (30%) and ~71/280 (25%) respectively are shared logic.

What's genuinely variant: the $fieldStyles (filled adds backgroundColor), $outlineStyles (border vs bottom-bar), $animatedActiveOutlineStyles (filled-only), $disabledBackgroundStyles (undefined in outlined, overlay in filled), and the labelBackgroundColor extraction
(outlined only).


styles.ts -- low duplication (~10%)

$labelTextStyle is byte-for-byte identical in both files (5 lines). It belongs in the shared ../styles.ts.

Everything else differs meaningfully:

  • $fieldStyle: same keys, borderRadius (outlined) vs borderTopStart/EndRadius (filled)
  • $outlineStyle: full-perimeter absolute (outlined) vs bottom-only absolute (filled)
  • $containerStyle: alignItems: 'center' (outlined) vs 'flex-end' (filled)
  • $labelWrapperStyle: outlined adds paddingHorizontal, filled is empty
  • $disabledBackgroundStyle: filled-only

constants.ts -- minimal duplication (~5%)

LABEL_START_OFFSET_WITHOUT_ACCESSORY resolves to TEXT_FIELD_INPUT_WRAPPER_PADDING_HORIZONTAL in both -- could live in shared ../constants.ts.

Everything else is variant: LABEL_PADDING_HORIZONTAL and the RTL translate constants are outlined-only; MULTILINE_PADDING_TOP is filled-only; ACTIVE_LABEL_TOP_POSITION uses different formulas; the opacity values differ (0.12 vs 0.04).


utils.ts -- 0% unifiable

Both export getOutlineColor but with incompatible signatures: outlined takes hasError: boolean, filled takes status?: 'error' | 'disabled'. Same name, different contract -- unifying them would require a signature change that ripples into both logic.ts files.


Overall

File Outlined lines Filled lines Duplicated %
logic.ts 233 280 ~71 ~28%
styles.ts 45 54 ~5 ~10%
constants.ts 49 35 ~2 ~5%
utils.ts 39 34 0 0%
Total 366 403 ~78 ~20%

@adrcotfas
Copy link
Copy Markdown
Collaborator

adrcotfas commented May 7, 2026

Some more issues I found:

  • Outlined + Multiline in combination with any leading icon/ trailing icon/ error/suffix/ prefix has a bug; give it a try as now it's much easier to manually test combinations
  • We should not allow prefix + suffix in the same field

Edit: the filled version lost the top rounded corners when in error mode and after toggling the "disabled" state.

Comment thread src/components/TextField/TextFieldIcon.tsx
@adrcotfas
Copy link
Copy Markdown
Collaborator

When the helper text is empty, the counter gets aligned to start. It should stay aligned to end.

@adrcotfas
Copy link
Copy Markdown
Collaborator

Error seems to take precedence over Disabled but I would expect that a disabled field is not editable even if it has "error".

@wonderlul
Copy link
Copy Markdown
Collaborator Author

Some more issues I found:

  • The filled version lost the top rounded corners Or not - it was a weird visual bug occuring once
  • Outlined + Multiline in combination with any leading icon/ trailing icon/ error/suffix/ prefix has a bug; give it a try as now it's much easier to manually test combinations
  • We should not allow prefix + suffix in the same field

Multi-line fixed.

I disagree with disallowing prefix + suffix. I think it's up to the developer to decide whether to use both, one or neither, but we shouldn't disable the option for developers.

@wonderlul
Copy link
Copy Markdown
Collaborator Author

Error seems to take precedence over Disabled but I would expect that a disabled field is not editable even if it has "error".

Yeah, this is on purpose — we’re using a clear order for those states.

My thinking was: if there’s an error, the field shouldn’t really be blocked. You usually need to change the value to fix it, so disabling it felt backwards.

If we’re okay with that idea, we can stick to this gradation and it keeps the code a lot simpler — less special cases.

I know some developers expect disabled to always win even when error is set; if that’s what we want product-wise, we can always revisit it. Let me know.

@wonderlul wonderlul requested a review from adrcotfas May 7, 2026 12:35
Copy link
Copy Markdown
Member

@satya164 satya164 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's keep the code style consistent with other parts of the codebase.

Avoid too many small helpers. The code is hard for me to follow because it's split between so many parts. Prefer normal hooks and components for composition instead of helper functions.

We're not using $ prefix for styles. Use StyleSheet.create API so it uses atomic styles on React Native Web. Avoid unnecessary inline styles.

Also, avoid comments like these:

// ============
// OPACITY
// ============

Use normal multiline or single-line comments when necessary and don't comment on the obvious.

@wonderlul
Copy link
Copy Markdown
Collaborator Author

Let's keep the code style consistent with other parts of the codebase.

Avoid too many small helpers. The code is hard for me to follow because it's split between so many parts. Prefer normal hooks and components for composition instead of helper functions.

We're not using $ prefix for styles. Use StyleSheet.create API so it uses atomic styles on React Native Web. Avoid unnecessary inline styles.

Also, avoid comments like these:

// ============
// OPACITY
// ============

Use normal multiline or single-line comments when necessary and don't comment on the obvious.

In the context of hooks vs. helpers, I see it more as a matter of convention and preference. If reusable logic does not manage application state, I usually prefer helper/util terminology over hooks. The helpers I created contain reusable logic, which is why I decided to extract them. Similarly, when a piece of logic becomes relatively large (e.g. getFilledTextFieldData), I tend to move it into a helper as well.

I split the logic into separate places because, from my perspective, it improves readability through clearer separation of responsibilities. Of course, this can be somewhat subjective. I think the current solution is a reasonable middle ground. I removed the filled and outlined folders and moved their logic into individual files within the main folder. Considering the complexity of the component, the current file structure feels like a good balance between readability and organization.

I also added the StyleSheet API for static styles, although most of the styles in this component are dynamic. I removed the $ notation as well.

As for comments, I only added them where I felt additional context improved readability — mainly around more complex functions, variables, styles, or to separate logical sections within files. Following your suggestion, I updated them to regular multiline or single-line comments.

@wonderlul wonderlul requested a review from satya164 May 14, 2026 08:55
@wonderlul wonderlul force-pushed the feat/TextField-v6 branch 4 times, most recently from bf4638d to 73642ae Compare May 14, 2026 10:59
@wonderlul wonderlul force-pushed the feat/TextField-v6 branch from e3d5f56 to daea379 Compare May 14, 2026 11:17
@adrcotfas adrcotfas added the v6 label May 14, 2026
Copy link
Copy Markdown
Member

@satya164 satya164 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the PR. I left few comments.

And found few issues:

  • Are inputs supposed to look this big on the web?
  • Alignment seems off for outlined input on the web
    Image
  • Tapping on leading icon in the input seems to unfocus in the input. Since it looks like part of the input, I'd expect it to act like tapping on the input. Ideally it should be positioned absolutely on top and text input should have padding to offset it
  • Measurements seems a bit off on iOS (the app doesn't build on Android, so haven't checked). The placeholder text seems larger and as a result has less space below it compared to the official example.
    Image
  • The input supports multiline but leading and trailing icons stay vertically centered. I'd expect them to be aligned at the top when there is multiline text.

Also I think while we should make breaking changes to align with MD3, it'd be valuable to keep the API same where we don't need to deviate.

  • TextField matches MD3 naming, but I think TextInput is better here since users already know it, and it'll be easier migration. So we should keep TextInput as the component name.
  • Missing props: textColor, underlineColor, activeUnderlineColor, outlineColor, activeOutlineColor, selectionColor, cursorColor - we should at leasy support selection and cursor color.
  • Missing render prop to render a custom input. This is necessary to use an input implementation that supports masking etc.

Comment on lines +24 to +26
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

please remove comments like this


const theme = useInternalTheme(themeOverride);

const [isFocused, setIsFocused] = useState<boolean>(false);
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

avoid re-renders unless absolutely necessary. if the focus state can be stored in a reanimated shared value, then it'd be better for performance.

* Constants
*/

const { isRTL } = I18nManager.getConstants();
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

use the useLocale hook to get writing direction.

*/

const { isRTL } = I18nManager.getConstants();
const disabled = props.editable === false;
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

imo editable={false} has a different meaning than disabled. editable={false} can be used to show a read-only text input while still signaling to the user that it's interactive (e.g., focusing and selecting the state). On web, you can see that input supports both disabled and readonly attributes with different behaviors.

we should add a separate disabled.


const { isRTL } = I18nManager.getConstants();
const disabled = props.editable === false;
const isFloating = isFocused || !!props.value;
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

relying on props.value means this component relies on controlled usage. controlled inputs in react native perform very poorly, and are not reliable enough. they also re-render the whole component on each keystroke.

given that one of the goal of the refactor is to improve performance, it needs to eliminate all unnecessary re-renders.

it should also be uncontrolled by default. controlled api should be supported, but not required.

utilize reanimated shared values to reduce re-render and store state such as this that'll affect styling.

Comment on lines +321 to +349
{!!disabledBackgroundStyles && (
<View pointerEvents="none" style={disabledBackgroundStyles} />
)}

{/* Inactive indicator — always-visible 1px bottom border (filled) or
full border (outlined); height and color reflect error/disabled state
but do not change on focus */}
<View pointerEvents="none" style={outlineStyles} />

{/* Active indicator — filled variant only; 2px bar that expands from
the center outward via scaleX (0 → 1) on focus and collapses on blur */}
{!!animatedActiveOutlineStyles && (
<Animated.View
pointerEvents="none"
style={animatedActiveOutlineStyles}
/>
)}

{!!label && (
<Animated.View
aria-hidden
pointerEvents="none"
style={animatedLabelWrapperStyles}
>
<Animated.Text {...labelProps} style={animatedLabelTextStyles}>
{label}
</Animated.Text>
</Animated.View>
)}
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I believe react-native-web warns if you pass pointerEvents as a prop. pass them in style instead.

Comment on lines +130 to +168
label?: string;
/**
* Pass any additional props directly to the label Text component.
*/
labelProps?: TextProps;
/**
* Supporting text to display below the input (Material Design 3). When
* `error` is `true`, this text is styled as an error message.
*/
supportingText?: string;
/**
* Pass any additional props directly to the supporting text `Text` component.
*/
supportingTextProps?: TextProps;
/**
* When `true`, displays a character counter below the input on the trailing
* side, showing `currentLength/maxLength`. Requires `maxLength` to be set.
*/
counter?: boolean;
/**
* Pass any additional props directly to the counter `Text` component.
*/
counterProps?: TextProps;
/**
* A short text string displayed at the start of the input (e.g. `"$"`).
*/
prefix?: string;
/**
* Pass any additional props directly to the prefix `Text` component.
*/
prefixProps?: TextProps;
/**
* A short text string displayed at the end of the input (e.g. `"/100"`).
*/
suffix?: string;
/**
* Pass any additional props directly to the suffix `Text` component.
*/
suffixProps?: TextProps;
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

not sure we need labelProps, supportingTextProps, counterProps, prefixProps, suffixProps. normally, we avoid exposing internals, otherwise it's easy to make breaking changes.

let's remove them. Adding them in the future if needed is easier than removing. make sure to keep the API surface small.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It depends. The current TextInput allows developers to override some styles or colors. I’d say we should either allow overriding everything or nothing at all — nothing in between. In my opinion, a component library should provide default styling with the ability to override it. Users can always do this by overriding the theme (since we use useInternalTheme), but in that case we’re only talking about colors, not behavior. Ultimately, I can remove the mentioned attributes assuming that will be sufficient. If they turn out to be needed, the community will definitely let us know.

Comment on lines +172 to +182
pressableStyle?: StyleProp<ViewStyle>;
/**
* Style overrides for the field container (the bordered row that includes
* StartAccessory, input content, and EndAccessory).
*/
fieldStyle?: StyleProp<ViewStyle>;
/**
* Style overrides for the input content wrapper (the area containing
* the label and TextInput, excluding accessories).
*/
containerStyle?: StyleProp<ViewStyle>;
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

same for pressableStyle, fieldStyle and containerStyle. do we need these? these expose internal implementation details.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It depends. The current TextInput allows developers to override some styles or colors. I’d say we should either allow overriding everything or nothing at all — nothing in between. In my opinion, a component library should provide default styling with the ability to override it. Users can always do this by overriding the theme (since we use useInternalTheme), but in that case we’re only talking about colors, not behavior. Ultimately, I can remove the mentioned attributes assuming that will be sufficient. If they turn out to be needed, the community will definitely let us know.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I’d say we should either allow overriding everything or nothing at all — nothing in between

what we expose should depend on actual use cases. exposing internal implementation detail means we can't change implementation without breaking user's code. which is fine if you're building a internal component library for a client. but it's better to be conservative for public libraries.

Comment on lines +135 to +139
/**
* Supporting text to display below the input (Material Design 3). When
* `error` is `true`, this text is styled as an error message.
*/
supportingText?: string;
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

wouldn't errorText be more intuitive if it's an error message?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we should stick with MD3 nomenclature.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

from MD documentation, it doesn't seem like supporting text is specific to errors, even tho error text is displayed as supporting text

CleanShot 2026-05-15 at 11 10 59@2x

otherwise it doesn't make much sense to say supporting text if it's only for error

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh it's not just for error. Maybe the description is misleading, I'll remove the part regarding error styling.

focusInput: () => void;
};

export interface TextFieldProps extends TextInputProps {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we use type aliases in rest of the codebase. keep it consistent.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants